"""
Lightweight library for accessing *some parts" of the FreshBooks API. Version 0.1

This version of the library includes access to Invoice and Client calls. The underlying structure
allows for trivial addition of new classes (see the APIClient class for an example).

See tests.py for sample usage. Do read the code of this module before using it, otherwise you are
bound to have problems.

I wrote this module, mainly because the official 'refreshbooks' lib has waaay too many dependencies.
Some ideas are borrowed from mattc58's Freshbooks.py (http://github.com/mattc58/freshbooks.py)

##############################################################################
MIT License
Copyright (c) 2010, E.Filipov <e.filipov@csc.bg>

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
##############################################################################
"""

__version__ = '0.1'

# Make sure to update these with your details. You can find the AUTH_TOKEN value 
# in My Account -> Freshbook API in the Freshbook web interface
ACCOUNT_USER = ''
AUTH_TOKEN = ''

# Modify anything below at your own risk

from xml.dom import minidom
import sys, os, datetime
import urllib, urllib2, cookielib
from odict import OrderedDict
from UserDict import DictMixin
import re

API_VERSION = '2.1'
ACCOUNT_URL = 'https://%s.freshbooks.com' % ACCOUNT_USER
SERVICE_URL = "%s/api/%s/xml-in" % (ACCOUNT_URL, API_VERSION)
USER_AGENT = 'Litebooks v%s (%s)' % (__version__, ACCOUNT_USER)



class APIError(Exception):
    pass

if not (ACCOUNT_USER and AUTH_TOKEN):
    raise APIError ('You must set ACCOUNT_USER and AUTH_TOKEN before you can use this module')

class ResponseStr (unicode):
    """Unicode class with added attribute - .xml_attrs"""
    def __init__ (self, *args, **kwargs):
        #super(ResponseStr,self).__init__ (*args, **kwargs)
        super(ResponseStr,self).__init__ ()
        self.xml_attrs = {}

class APIResponse (object, DictMixin):
    """Response object returned after an API call"""
    def __init__ (self, xml):
        self.init_xml = unicode(xml)
        self.doc = minidom.parseString(self.init_xml.strip())
        self.odict = self.node_to_dict (self.doc)
        self.error = None
        
        if not 'response' in self.odict:
            raise APIError ('Response XML has no "response" element!')
        if not self['response'].xml_attrs:
            raise APIError ('Response XML has a "response" element but it has no attributes!')
        
        # chroot the object dictionary to .['response']
        self.odict = self.odict['response']
        # due to the chroot, self['response'].xml_attrs becomes self.odict.xml_attrs
        if not self.odict.xml_attrs['status'].lower() == 'ok':
            self.error = self['error']
        
    def node_to_dict(self, node):
        """
        Recursively transform XML structure to a dictionary.
        Caveat: so far, if there are multiple keys with matching names at the same level of the XML,
        only the complex node (a node with subnodes) will get recorded in the dict
        """
        
        dictionary = OrderedDict()
        value = ResponseStr(u'')
        for child in node.childNodes:
            if child.nodeType == minidom.Node.TEXT_NODE:
                if child.nodeName in dictionary:
                    # this is to avoid overwriting of complex nodes by text nodes
                    # for the same key. 
                    continue
                value = ResponseStr(child.nodeValue)
            else:
                dictionary[child.nodeName]= self.node_to_dict (child)
                dictionary[child.nodeName].xml_attrs = OrderedDict(child.attributes.items())
        return dictionary or value
    
    def __repr__ (self):
        return self.odict.__repr__()
    def __str__(self):
        return self.odict.__str__()
    
    # Functions used by DictMixin to have the APIResponse mimick a dict class
    def __getitem__ (self, item):
        return self.odict.__getitem__ (item)
    
    def __setitem__ (self, item, value):
        return self.odict.__getitem__ (item,value)
    
    def __delitem__ (self, item):
        return self.odict.__delitem__ (item)
    
    def keys(self):
        return self.odict.keys()

class APIRequest(object):
    """
    Class encapsulating a generic API request. 
    The constructor takes the API method name, and a dictionary with the request data
    """
    def __init__ (self, method_name, input_dict):
        self.doc = minidom.Document()
        self.rq_node = self.doc.createElement('request')
        self.rq_node.setAttribute('method', method_name)
        self.doc.appendChild(self.rq_node)
        
        self.dict_to_node (input_dict)
        
    def item_to_node (self,item, root):
        if isinstance(item,datetime.datetime):
            str_rep = datetime.datetime.strptime(val, '%Y-%m-%d %H:%M:%S')
        else:
            str_rep = str(item)
        root.appendChild(self.doc.createTextNode(str_rep))

        
    def dict_to_node (self, input_dict, root=None):
        """Transform the input dict into proper XML"""
        if not root:
            root = self.rq_node
        for key, value in input_dict.iteritems():
            if value is None: continue
            element = self.doc.createElement (str(key))
            if isinstance(value,dict):
                self.dict_to_node (value, root=element)
            elif isinstance (value, list):
                for v in value:
                    self.dict_to_node (v, root=element)
            else:
                self.item_to_node (value, root = element)
            root.appendChild(element)
            
    def send (self):
        # HTTP Basic auth pwd management
        pwd_manager = urllib2.HTTPPasswordMgrWithDefaultRealm()
        pwd_manager.add_password (realm=None, uri=SERVICE_URL,user=AUTH_TOKEN,passwd='X')
        auth_handler = urllib2.HTTPBasicAuthHandler(pwd_manager)
        opener = urllib2.build_opener(auth_handler)
        
        # generate request body and user agent
        request = urllib2.Request(SERVICE_URL, self.doc.toxml(encoding="utf-8"))
        request.add_header('User-Agent',USER_AGENT)
    
        response = opener.open(request)
        api_resp =  APIResponse(response.read())
        if api_resp.error:
            raise APIError (api_resp.error)
        return api_resp
    
    
class BaseAPIEntity (object):
    """ 
    A class that provides some common FreshBook-API related fucntions
    for creating and destroying entities (like Clients and Invoices)
    """
    create_elements = ()
    id_field = ''
    name = 'generic'
    
    def __init__ (self, input_dict = None, id = None):
        self.odict = OrderedDict()
        if input_dict:
            for element in (self.id_field,) + self.create_elements:
                self.odict.setdefault (element, input_dict.get(element))
    
    def update_id (self,creation_response):
        """Updates the entity ID by looking at the creation_response"""
        if self.id_field in creation_response:
            self.id = creation_response[self.id_field]
            
    @property
    def id (self):
        return self.odict.get (self.id_field, None)
    
    @id.setter
    def id (self,newid):
        self.odict[self.id_field] = newid
                
    @property
    def rq_create (self):
        return APIRequest('%s.create' % self.name,{self.name:self.odict})

    @property
    def rq_delete (self):
        if not self.id:
            raise APIError ("The %s field must be set before a delete request can be fired" % self.id_field)
        return APIRequest ('%s.delete' % self.name, {self.id_field:self.id})
    
    @property
    def rq_get (self):
        if not self.id:
            raise APIError ("The %s field must be set before a get request can be fired" % self.id_field)
        return APIRequest('%s.get' % self.name,{self.id_field:self.id})
    
    def do_create (self):
        """ This function sends the request generated by self.rq_create and returns the response.
        It also updates the object id with the value returned in the response"""
        response = self.rq_create.send()
        self.update_id(response)
        return response
    
    def do_delete (self):
        """This function sends the request generated by self.rq_delete and returns the response"""
        return self.rq_delete.send()
    
    def do_get (self):
        """This function sends the request generated by self.rq_get and returns the response"""
        return self.rq_get.send()
    
class APIInvoice (BaseAPIEntity):
    """
    Class encapsulating possible calls to the invoice_* commands of the FreshBooks API.
    
    This calss also contains a lot of code aimed at downloading the invoice PDF that FB generates.
    It also includes some code aimed at scraping the HTML form used for online payments. The
    payment form scraping procedure has only been tested with 2CheckOut, so it is quite probable
    that it wont work with other/multiple payment processors.
    
    The class features implicit caching, so it would not download content it has already accessed
    """
    
    create_elements = ('client_id', 'number', 'status', 'date', 'po_number','discount','notes',
                         'currency_code', 'terms', 'return_uri', 'first_name','last_name',
                         'organization','p_street1','p_street2','p_city','p_state','p_country',
                         'p_code','vat_name','vat_number')
    line_elements = ('name','description','unit_cost','quantity','tax1_name','tax2_name',
                         'tax1_percent','tax2_percent','type')
    id_field = 'invoice_id'
    name = 'invoice'
    
    def __init__ (self, input_dict = None):
        super(APIInvoice,self).__init__ (input_dict)
        # property caches
        self._payment_form = None
        self._payment_url = None
        self._cookie_opener = None
        self._client_url = None
        self._client_html = None
        self._pdf_stream = None
        # this is somewhat unique to the invoice reqeust - multiple invoice lines
        if input_dict:
            for linedict in input_dict.get('lines',[]):
                self.add_line (linedict)
    
    def add_line (self, line_dict):
        """Add a single line to the invoice dictionary"""
        linelist = self.odict.setdefault ('lines',[])
        line = OrderedDict()
        for element in self.line_elements:
            line.setdefault(element, line_dict.get(element))
        linelist.append({'line':line})
        return line
    
    def get_client_url (self):
        """
        Returns the 'client_view' URL of a Freshbook invoice. The invoice id must be
        properly set prior to calling this
        """
        response = self.do_get()
        self._client_url = response['invoice']['links']['client_view']   
        return self._client_url
    
    def get_pdf_stream (self):
        """
        Scrapes the PDF stream off the client_view URL. We resort to scraping since the 
        FreshBook API is not very usefful for getting just the PDF
        """
        p = self.cookie_opener.open('https://rblmon.freshbooks.com/getPDF.php', 
                urllib.urlencode ( {'invoiceid[]':self.id,'disposition':'attachment'}))
        self._pdf_stream = p.read()
        return self._pdf_stream
    
    def init_scraper (self):
        """
        Refreshes the cache of self._cookie_opener and self._client_html
        """
        cj = cookielib.CookieJar()
        self._cookie_opener = urllib2.build_opener(urllib2.HTTPCookieProcessor(cj))
        # we open up the client URL so we can get the auth cookie set
        req = self._cookie_opener.open(self.client_url) 
        # we save the HTML so we don't have to request it again
        self._client_html = req.read()
        return {'cookie_opener':self._cookie_opener, 'client_html':self._client_html}
    
    
    def scrape_payment_url (self):
        # parsing HTML with regexp sucks! 
        link_pattern = re.compile('<a href="(https://.*)" class=".*"><span>Pay</span></a>' )
        redirect_pattern = re.compile("window.location='(.*)';" )
        
        # first we get the link off the client view page
        result = link_pattern.search (self.client_html)
        if not result:
            raise APIError('Cannot scrape payment link off %s !' % self.client_url)
        
        # then we get the papayment page off the first address
        final_url = result.group(1)
        final_page = self.cookie_opener.open (final_url).read()
        result = redirect_pattern.search (final_page)
        if not result:
            raise APIError('Cannot scrape payment link off (middle page) %s !' % final_url)

        self._payment_url = result.group(1)
        return self._payment_url
    
    def scrape_payment_form (self):
        """
        This is a function that does some very basic text processing to get the HTML payment form 
        out of the freshbooks page
        """
        
        # TODO: improve the fragile scraping
        clean_up_strings = ['<div align="center">','</div>','<br />','<strong>','</strong>',
                            '<ul>','</ul>','onSubmit="return Validate(form)"','\t','\n\n']

        rq = self.cookie_opener.open (self.payment_url)
        payment_html = rq.read()
        
        frm_start = payment_html.find('<form id="payment_form"')
        frm_end = payment_html[frm_start:].find('</form>')
        if (frm_start == -1) or (frm_end == -1):
            raise APIError ("Can't locate payment form start/end!")
        
        # the offset to frm_end is added to cover the lenght of '</form>'
        result = payment_html[frm_start:frm_start + frm_end + 7 ]
        for s in clean_up_strings:
            # remove the strings found in clean_up_strings 
            result = result.replace(s,'')
        self._payment_form = result
        return self._payment_form
    
    @property
    def payment_form (self):
        """This property acts like a cache, avoiding new requests"""
        return self._payment_form or self.scrape_payment_form()
    
    @property
    def payment_url(self):
        """This property acts like a cache, avoiding new requests"""
        return self._payment_url or self.scrape_payment_url()
    
    @property
    def client_html(self):
        """This property acts like a cache, avoiding new requests"""
        return self._client_html or self.init_scraper()['client_html']

    @property
    def cookie_opener (self):
        """This property acts like a cache, avoiding new requests"""
        return self._cookie_opener or self.init_scraper()['cookie_opener']
        
    @property
    def client_url(self):
        """
        This property acts like a cache, avoiding a new API request when the client_url
        is already known. To refresh this value simply call .get_client_url()
        """
        return self._client_url or self.get_client_url()
    
    @property
    def pdf_stream(self):
        """
        This property acts like a cache, avoiding a new API request when the pdf_stream
        is already known. To refresh this value simply call .get_pdf_steam()
        """
        return self._pdf_stream or self.get_pdf_stream()
    
    
class APIClient (BaseAPIEntity):
    """Class encapsulating possible calls to the client_* commands of the FreshBooks API"""
    create_elements = ('first_name','last_name','organization','email','username',
                     'password','work_phone', 'home_phone','mobile','fax','notes','p_street1',
                     'p_street2','p_city','p_state','p_country','p_code','s_street1','s_street2',
                     's_city','s_state','s_country','s_code','vat_name','vat_number')
    
    id_field = 'client_id'
    name = 'client'
    